跳到主要内容

OAuth2 协议学习

参考资料 《认证授权基础》 参考资料 OAuth 2.0 的一个简单解释 参考资料 GitHub OAuth 第三方登录示例教程

认证 (Authentication) 和授权 (Authorization)的区别是什么?

认证 (Authentication): 你是谁。

Authentication(认证) 是验证您的身份的凭据(例如用户名/用户ID和密码),通过这个凭据,系统得以知道你就是你,也就是说系统存在你这个用户。所以,Authentication 被称为身份/用户验证。

授权 (Authorization): 你有权限干什么。

Authorization(授权) 发生在 Authentication(认证) 之后。授权嘛,光看意思大家应该就明白,它主要掌管我们访问系统的权限。比如有些特定资源只能具有特定权限的人才能访问比如 admin,有些对系统资源操作比如删除、添加、更新只能特定人才具有。

什么是OAuth 2.0?

180px-Oauth_logo.svg.png

OAuth 2.0是一个业界标准的 授权协议

第三方认证技术方案最主要是解决认证协议的通用标准问题,因为要实现跨系统认证,各系统之间要遵循一定的接口协议。所以 OAuth 协议为用户资源的授权提供了一个安全的、开放而又简单的标准。同时,任何第三方都可以使用 OAuth 认证服务,所以有针对各种语言的开发包(PHP、JavaScript、Java、Ruby 等)

实际上它就是一种授权机制,它的最终目的是为第三方应用颁发一个有时效性的令牌 token,使得第三方应用能够通过该令牌获取相关的资源。

OAuth 引入了一个授权层,用来分离两种不同的角色:客户端和资源所有者。......资源所有者同意以后,资源服务器可以向客户端颁发令牌。客户端通过令牌,去请求数据。即 OAuth 的核心就是向第三方应用颁发令牌

而 OAuth 2.0 是对 OAuth 1.0 的完全重新设计,OAuth 2.0更快,更容易实现,OAuth 1.0 已经被废弃。

OAuth 2.0 比较常用的场景就是第三方登录,当你的网站接入了第三方登录的时候一般就是使用的 OAuth 2.0 协议。

在 OAuth 2.0 的协议交互中,有四个角色的定义

  • 资源所有者(Resource Owner):顾名思义,拥有资源的所有权的人。
  • 资源服务器(Resource Server):保存着受保护的用户资源。
  • 应用程序(Client):准备访问用户资源的应用程序。
  • 授权服务器(Authorization Server):授权服务器,在获取用户的同意授权后,颁发访问令牌给应用程序,以便其获取用户资源。

由于互联网有多种场景,所以这个标准定义了获得令牌的四种授权方式(authorization grant)。也就是说,OAuth 2.0 规定了四种获得令牌的流程。你可以选择最适合自己的那一种,向第三方应用颁发令牌。下面就是这四种授权方式。

  • 授权码(authorization-code)
  • 隐藏式(implicit)
  • 密码式(password)
  • 客户端凭证(client credentials)

注意,不管哪一种授权方式,第三方应用申请令牌之前,都必须先到系统备案,说明自己的身份,然后会拿到两个身份识别码:客户端 ID(client ID)和客户端密钥(client secret)。这是为了防止令牌被滥用,没有备案过的第三方应用,是不会拿到令牌的。

授权码模式

授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。

这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。

授权流程场景可以描述为如下几个步骤

第一步:A 网站提供一个链接,用户点击后就会跳转到 B 网站,授权用户数据给 A 网站使用。下面就是 A 网站跳转 B 网站的一个示意链接。

https://b.com/oauth/authorize?
response_type=code&
client_id=CLIENT_ID&
redirect_uri=CALLBACK_URL&
scope=read

上面 URL 中,response_type 参数表示要求返回授权码(authorization code),client_id 参数让 B 知道是谁在请求,redirect_uri 参数是 B 接受或拒绝请求后的跳转网址,scope 参数表示要求的授权范围(这里是只读)。

┌───────────┐                                      ┌───────────┐
│ │ 1. request authorization code │ │
│ A.com ├─────────────────────────────────────►│ B.com │
│ │ │ │
└───────────┘ └───────────┘

第二步,用户跳转后,B 网站会要求用户登录,然后询问是否同意给予 A 网站授权。用户表示同意,这时 B 网站就会跳回 redirect_uri 参数指定的网址。跳转时,会传回一个授权码,就像下面这样。

https://a.com/callback?code=AUTHORIZATION_CODE

上面 URL 中,code 参数就是授权码。

┌───────────┐                                      ┌───────────┐
│ │ 1. request authorization code │ │
│ A.com ├─────────────────────────────────────►│ B.com │
│ │ │ │
│ │ 2. response authorization code │ │
│ │◄─────────────────────────────────────┤ │
│ │ │ │
└───────────┘ └───────────┘

第三步,A 网站拿到授权码以后,就可以在后端,向 B 网站请求令牌。

https://b.com/oauth/token?
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET&
grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=CALLBACK_URL

上面 URL 中,client_id 参数和 client_secret 参数用来让 B 确认 A 的身份(client_secret 参数是保密的,因此只能在后端发请求),grant_type 参数的值是 AUTHORIZATION_CODE,表示采用的授权方式是授权码,code 参数是上一步拿到的授权码, redirect_uri 参数是令牌颁发后的回调网址。


┌───────────┐ ┌───────────┐
│ │ 1. request authorization code │ │
│ A.com ├─────────────────────────────────────►│ B.com │
│ │ │ │
│ │ 2. response authorization code │ │
│ │◄─────────────────────────────────────┤ │
│ │ │ │
│ │ 3. request access_token │ │
│ ├─────────────────────────────────────►│ │
│ │ │ │
└───────────┘ └───────────┘

第四步,B 网站收到请求以后,就会颁发令牌。具体做法是向 redirect_uri 指定的网址,发送一段 JSON 数据。

{    
"access_token":"ACCESS_TOKEN",
"token_type":"bearer",
"expires_in":2592000,
"refresh_token":"REFRESH_TOKEN",
"scope":"read",
"uid":100101,
"info":{...}
}

上面 JSON 数据中,access_token 字段就是令牌,A 网站在后端拿到了。(注意,是在后端去取得 Token 的)


┌───────────┐ ┌───────────┐
│ │ 1. request authorization code │ │
│ A.com ├─────────────────────────────────────►│ B.com │
│ │ │ │
│ │ 2. response authorization code │ │
│ │◄─────────────────────────────────────┤ │
│ │ │ │
│ │ 3. request access_token │ │
│ ├─────────────────────────────────────►│ │
│ │ │ │
│ │ 4. response access_token │ │
│ │◄─────────────────────────────────────┤ │
└───────────┘ └───────────┘

其整个授权流程如下

image.png

为什么需要授权码

参考资料 Oauth2的授权码模式为什么要用code获取token? - 陈涛的回答

这个问题其实很好理解,我们想象一下整个过程!假如没有这个 code,而是直接返回 token 会出现什么问题呢?

1、张三访问A网,使用 QQ 授权登录,跳转到 QQ 授权页

2、完成 QQ 授权,跳转到 A网页面,并在参数中假如 token = 123456

3、A网获取 token 后,再去调用 API 完成相应操作。

注意,第二步的 token,是在你的浏览器里实现的,跳转内容,也会在浏览器的地址输入框中体现。这就存在安全的风险,比如你恰好要分享内容拷贝了这个链接呢?又或者你的浏览器中毒了,被监听了这个链接呢? 总之是不安全的。

如果返回的是一个 CODE,获取 token 的环节是在 A网站后台实现的,不会被泄露。你自己也看不到真正的 token。

CODE如果泄露了怎么办?

一般来说 CODE 只能兑换一次 token,如果你获取 CODE 后,无法授权。则系统自然会发现被黑客攻击了。会重新授权,那么之前的 token 就无效了。

隐藏式

有些 Web 应用是纯前端应用,没有后端。这时就不能用上面的方式了,必须将令牌储存在前端。RFC 6749 就规定了第二种方式,允许直接向前端颁发令牌。这种方式没有授权码这个中间步骤,所以称为(授权码)"隐藏式"(implicit)。

第一步,A 网站提供一个链接,要求用户跳转到 B 网站,授权用户数据给 A 网站使用。

https://b.com/oauth/authorize?
response_type=token&
client_id=CLIENT_ID&
redirect_uri=CALLBACK_URL&
scope=read

上面 URL 中,response_type 参数为 token,表示要求直接返回令牌。

第二步,用户跳转到 B 网站,登录后同意给予 A 网站授权。这时,B 网站就会跳回 redirect_uri 参数指定的跳转网址,并且把令牌作为 URL 参数,传给 A 网站。

https://a.com/callback#token=ACCESS_TOKEN

上面 URL 中,token 参数就是令牌,A 网站因此直接在前端拿到令牌。

注意,令牌的位置是 URL 锚点(fragment),而不是查询字符串(querystring),这是因为 OAuth 2.0 允许跳转网址是 HTTP 协议,因此存在 "中间人攻击" 的风险,而浏览器跳转时,锚点不会发到服务器,就减少了泄漏令牌的风险。

┌───────────┐                                      ┌───────────┐
│ │ 1. request access_token │ │
│ A.com ├─────────────────────────────────────►│ B.com │
│ │ │ │
│ │ 2. response access_token │ │
│ │◄─────────────────────────────────────┤ │
│ │ │ │
└───────────┘ └───────────┘

这种方式把令牌直接传给前端,是很不安全的。因此,只能用于一些安全要求不高的场景,并且令牌的有效期必须非常短,通常就是会话期间(session)有效,浏览器关掉,令牌就失效了。

密码模式

如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为"密码式"(password)。

第一步,A 网站要求用户提供 B 网站的用户名和密码。拿到以后,A 就直接向 B 请求令牌。

https://oauth.b.com/token?
grant_type=password&
username=USERNAME&
password=PASSWORD&
client_id=CLIENT_ID

上面 URL 中,grant_type 参数是授权方式,这里的 password 表示"密码式",username 和 password 是 B 的用户名和密码。

第二步,B 网站验证身份通过后,直接给出令牌。注意,这时不需要跳转,而是把令牌放在 JSON 数据里面,作为 HTTP 回应,A 因此拿到令牌。

在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。(或者同一个应用的 Android 端、iOS 端、Web 端)而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。

步骤也很简单

  1. 用户向客户端提供用户名和密码。
  2. 客户端将用户名和密码发给认证服务器,向后者请求令牌。
  3. 认证服务器确认无误后,向客户端提供访问令牌。

凭证式

参考资料 OAuth2.0系列五:OAuth2.0客户端凭证

如果信任关系再进一步,或者调用者是一个后端的模块(例如微服务的模块),没有用户界面的时候,可以使用凭证式(客户端模式)。鉴权服务器直接对客户端进行身份验证,验证通过后,返回 token。

第一步,A 应用在命令行向 B 发出请求。

https://oauth.b.com/token?
grant_type=client_credentials&
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET

上面 URL 中,grant_type 参数等于 client_credentials 表示采用凭证式,client_idclient_secret 用来让 B 确认 A 的身份。

第二步,B 网站验证通过以后,直接返回令牌。

这种方式给出的令牌,是针对第三方应用的,而不是针对用户的,即有可能多个用户共享同一个令牌。

注意,这里要为空,否则会验证两次导致失败

令牌的使用

A 网站拿到令牌以后,就可以向 B 网站的 API 请求数据了。

此时,每个发到 API 的请求,都必须带有令牌。具体做法是在请求的头信息,加上一个 Authorization 字段,令牌就放在这个字段里面。

curl -H "Authorization: Bearer ACCESS_TOKEN" \
"https://api.b.com"

上面命令中,ACCESS_TOKEN 就是拿到的令牌。

更新令牌

令牌的有效期到了,如果让用户重新走一遍上面的流程,再申请一个新的令牌,很可能体验不好,而且也没有必要。OAuth 2.0 允许用户自动更新令牌。

具体方法是,B 网站颁发令牌的时候,一次性颁发两个令牌,一个用于获取数据,另一个用于获取新的令牌(refresh token 字段)。令牌到期前,用户使用 refresh token 发一个请求,去更新令牌。

B 网站验证通过以后,就会颁发新的令牌。

OAuth2 模式该如何选型?

首先明确一下 OAuth 的各种概念

  • 资源所有者:可以授予受保护资源的最终用户。
  • 客户端:代表资源所有者请求访问受保护资源的应用程序。
  • 资源服务器:持有受保护资源的服务,也就是你要访问的 api 接口。
  • 授权服务器:认证资源所有者以及在获得正确的认证之后颁发 access_token 的服务。
  • 用户代理:是一种被资源所有者去和客户端产生交互的代理(例如浏览器)。

这里参考极客空间的图 参考视频 OAuth2 模式该如何选型

把客户应用分成两类,一类是在客户手中的公开类型(APP、Web 之类的),另一类是服务端的

下面介绍各种模式它们适用的场景(这里密码模式就是自己的 APP 使用)

根据流程选型

GitHub OAuth 登陆示例

很多网站登录时,允许使用第三方网站的身份,这称为"第三方登录"。

下面就以 GitHub 为例,写一个最简单的应用,演示第三方登录。

第三方登录的原理(授权码模式)

所谓第三方登录,实质就是 OAuth 授权。用户想要登录 A 网站,A 网站让用户提供第三方网站的数据,证明自己的身份。获取第三方网站的身份数据,就需要 OAuth 授权。

举例来说,A 网站允许 GitHub 登录,背后就是下面的流程。

  1. A 网站让用户跳转到 GitHub。
  2. GitHub 要求用户登录,然后询问 "A 网站要求获得 xx 权限,你是否同意?"
  3. 用户同意,GitHub 就会重定向回 A 网站,同时发回一个授权码。
  4. A 网站使用授权码,向 GitHub 请求令牌。
  5. GitHub 返回令牌.
  6. A 网站使用令牌,向 GitHub 请求用户数据。

下面就是这个流程的代码实现。

应用登记

一个应用要求 OAuth 授权,必须先到对方网站登记,让对方知道是谁在请求。

所以首先去 GitHub 登记一下。

GitHub 应用注册 这个网站

应用的名称随便填,主页 URL 填写 http://localhost:8080,跳转网址(就是用户同意授权后会发送的请求)填写 http://localhost:8080/oauth/redirect

提交表单以后,GitHub 应该会返回客户端 ID(client ID)和客户端密钥(client secret),这就是应用的身份识别码。

编写后端代码

这里直接使用 NodeJS 来编写后台

先添加依赖:

npm install axios

# koa 是一个新的 web 框架,由 Express 幕后的原班人马打造
# 中文文档:https://koa.bootcss.com/
npm install koa
npm install koa-route
npm install koa-static

后台的代码

const clientID = 'clientID'
const clientSecret = 'clientSecret'

const Koa = require('koa');
const path = require('path');
const serve = require('koa-static');
const route = require('koa-route');
const axios = require('axios');

const app = new Koa();

const main = serve(path.join(__dirname + '/public'));

// 这个 oauth 是下面的 /oauth/redirect 请求的回调函数
// GitHub 对这个请求的地址是:
// http://localhost:8080/oauth/redirect?code=3059984a65ca709cc44cfde084aeac40ed1e8a07
const oauth = async ctx => {
// 这个 code 就是授权码
const requestToken = ctx.request.query.code;
console.log('authorization code:', requestToken);

// 根据授权码取得令牌
const tokenResponse = await axios({
method: 'post',
url: 'https://github.com/login/oauth/access_token?' +
`client_id=${clientID}&` +
`client_secret=${clientSecret}&` +
`code=${requestToken}`,
headers: {
accept: 'application/json'
}
});

// 获取上面请求得到的 token
const accessToken = tokenResponse.data.access_token;
console.log(`access token: ${accessToken}`);

// 根据这个 令牌 拿到用户信息
const result = await axios({
method: 'get',
url: `https://api.github.com/user`,
headers: {
accept: 'application/json',
Authorization: `token ${accessToken}`
}
});
console.log(result.data);
const name = result.data.name;

// 最后将这个请求重定向到 welcome.html 页面
ctx.response.redirect(`/welcome.html?name=${name}`);
};

app.use(main);
// 监听上面填写的完成授权后会跳转的 URL 地址
app.use(route.get('/oauth/redirect', oauth));

app.listen(8080);

编写前端逻辑

index 页面显示的内容

<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Node OAuth2 Demo</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body>
<a id="login">Login with GitHub</a>

<script>

// 这个 URL 指向 GitHub 的 OAuth 授权网址,带有两个参数:client_id告诉 GitHub 谁在请求,redirect_uri是稍后跳转回来的网址。
const client_id = 'client_id';

const authorize_uri = 'https://github.com/login/oauth/authorize';
const redirect_uri = 'http://localhost:8080/oauth/redirect';

const link = document.getElementById('login');
link.href = `${authorize_uri}?client_id=${client_id}&redirect_uri=${redirect_uri}`;
</script>

</body>

</html>

编写 welcome 页

<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Hello</title>
</head>

<body>
</body>
<script>
const query = window.location.search.substring(1) || '';
const name = query.split('name=')[1];
document.body.appendChild(
document.createTextNode(`Welcome, ${decodeURIComponent(name)}`)
);
</script>

</html>

执行流程

浏览器访问 http://localhost:8080,就可以看到这个示例了

用户点击到了 GitHub,GitHub 会要求用户登录,确保是本人在操作

登录后,GitHub 询问用户,该应用正在请求数据,你是否同意授权。

用户同意授权, GitHub 就会跳转到 redirect_uri 指定的跳转网址,并且带上授权码,跳转回来的 URL 就是下面的样子。

http://localhost:8080/oauth/redirect?
code=32e19fdb41ae6ea48747

后端收到这个请求以后,就拿到了授权码(code参数)。

这里的关键是针对 /oauth/redirect 的请求,编写一个路由,完成 OAuth 认证。

const oauth = async ctx => {
// ...
};

app.use(route.get('/oauth/redirect', oauth));

上面代码中,oauth 函数就是路由的处理函数。下面的代码都写在这个函数里面(具体看上面的后端代码)。

路由函数的第一件事,是从 URL 取出授权码。

const requestToken = ctx.request.query.code;

后端使用这个授权码,向 GitHub 请求令牌。

// 这里的这个 requestToken 就是上面取得的 code 授权码
const tokenResponse = await axios({
method: 'post',
url: 'https://github.com/login/oauth/access_token?' +
`client_id=${clientID}&` +
`client_secret=${clientSecret}&` +
`code=${requestToken}`,
headers: {
accept: 'application/json'
}
});

上面代码中,GitHub 的令牌接口 https://github.com/login/oauth/access_token 需要提供三个参数。

  • client_id:客户端的 ID
  • client_secret:客户端的密钥
  • code:授权码

作为回应,GitHub 会返回一段 JSON 数据,里面包含了令牌 accessToken。

const accessToken = tokenResponse.data.access_token;

有了令牌以后,就可以向 API 请求数据了。

const result = await axios({
method: 'get',
url: `https://api.github.com/user`,
headers: {
accept: 'application/json',
Authorization: `token ${accessToken}`
}
});

上面代码中,GitHub API 的地址是 https://api.github.com/user,请求的时候必须在 HTTP 头信息里面带上令牌 Authorization: token 305998xxxxxxxxxxxxx

然后,就可以拿到用户数据,得到用户的身份。

最后看控制台打印的信息

{
login: 'alsritter',
id: 46472610,
node_id: 'MDQ6VXNlcjQ2NDcyNjEw',
avatar_url: 'https://avatars.githubusercontent.com/u/46472610?v=4',
gravatar_id: '',
url: 'https://api.github.com/users/alsritter',
html_url: 'https://github.com/alsritter',
followers_url: 'https://api.github.com/users/alsritter/followers',
following_url: 'https://api.github.com/users/alsritter/following{/other_user}',
gists_url: 'https://api.github.com/users/alsritter/gists{/gist_id}',
starred_url: 'https://api.github.com/users/alsritter/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/alsritter/subscriptions',
organizations_url: 'https://api.github.com/users/alsritter/orgs',
repos_url: 'https://api.github.com/users/alsritter/repos',
events_url: 'https://api.github.com/users/alsritter/events{/privacy}',
received_events_url: 'https://api.github.com/users/alsritter/received_events',
type: 'User',
site_admin: false,
name: 'ALSRitter',
company: null,
blog: 'https://alsritter.icu/',
location: null,
email: null,
hireable: null,
bio: "You can't do it—that's the biggest lie on earth. ",
twitter_username: null,
public_repos: 6,
public_gists: 0,
followers: 3,
following: 1,
created_at: '2019-01-08T04:58:00Z',
updated_at: '2021-03-22T07:14:06Z'
}

References